先说结论,在 Vue 中,单向数据流和双向数据绑定并不冲突,因为这两个东西所处的场景不同。
双向数据绑定
单/双向数据绑定,指的是视图层和数据层之间的映射关系
Vue 在数据操作上,同时支持单向数据绑定和双向数据绑定:
- 单向绑定:例如 Mustache 插值语法,
v-bind
等,数据改变,视图也跟着改变; - 双向绑定:即表单的
v-model
。它实际上是一个语法糖,背后包括两步操作:v-bind:value
:数据改变,视图跟着改变v-on:input
:视图改变,数据跟着改变
实现双向数据绑定的核心思路是什么?
双向数据绑定是通过数据劫持+发布-订阅模式实现的。有几个核心的模块,分别是 Watcher(数据拦截)、Observer(订阅者)、Subject(发布者)、Queue(消息队列)。
假设现在要针对 obj 对象实现双向数据绑定,首先会把 obj 传给 Watcher,Watcher 内部会遍历 obj 的每一个 key,为其添加 setter 和 getter,最后把加工好的 obj 返回。接着遍历 obj,产生一个 Queue,key 仍然是 obj 的 key,value 则是一个 Subject 发布者。Subject 自身维护一个订阅者数组,通过 attach 方法往数组中添加订阅者,通过 notify 发布事件,将订阅者对应的回调函数一一执行。Observer 订阅者所在的事情,就是调用 Subject 的 attach 方法添加一个回调函数。
假设 obj 对象的 a 属性改变了,那么就会触发 setter 函数,setter 函数里面会调用 Queue[a] 这个发布者的 notify 方法,做一个事件的发布,而 notify 方法会把发布者自身维护的数组里面的回调函数取出来一一执行。那么,这里的回调函数会做什么事情呢?其实我们可以把订阅者看作是所有依赖 a 属性的 dom 元素,回调函数做的事情就是根据目前最新的 a 属性做一个视图的更新。
单向数据流
单向数据流,指的是组件之间的数据流动是单向的
单向数据流的表现
假设子组件接受了父组件的 prop
,想要对其做一个双向绑定,那么我们的代码可能会这么写:
<div id="app">
<cpn v-bind:value2="value"></cpn>
</div>
<template id="cpn">
<input type="text" v-model="value2">
<h2>{{value2}}</h2>
</template>
const cpn = {
template:"#cpn",
props:["value2"]
}
const app = new Vue({
el:'#app',
data:{
value:0
},
components:{
cpn
}
})
我们会发现 model 层的确随着 view 层同步改变了,但是控制台里会报错:
这是因为,prop
是父组件传过来的原始数据,而我们现在试图通过子组件的 v-model
去改变这个 prop
,也就是试图通过子组件直接去改变父组件的数据(而不是通过发送事件的方式),这是不允许的,因为 Vue 是单向数据流 —— 也就是说,数据总是从父组件传到子组件,子组件没有权利修改父组件传过来的数据,只能请求父组件对原始数据进行修改。
为什么是单向数据流
在设计上为什么是单向数据流呢?
- 首先第一个是方便追踪状态变更。如果是双向数据流,则任意一个组件都可以修改父组件的
prop
,那么状态变更的追踪将会很困难 - 第二个是实现组件状态解耦。如果是双向数据流,则任意一个组件都可以修改父组件的
prop
,这会影响到那些同样接受了父组件prop
的子组件 - 第三个是从函数式编程的角度理解。实际上组件可以视为一个函数,那么
prop
就是它接受的参数,如果说函数可以修改到这个参数,那么这个函数就是有副作用的,不是纯函数。而组件在设计上应该是一个“纯函数”,这意味着它无法改变入参的值。
单向数据流下如何修改父组件的数据
但是,很多时候我们又确实要操作这个数据,那么应该怎么办呢?
有两种方法:
- 定义一个局部变量,并用
prop
的值初始化它:
props: ['initialCounter'],
data: function () {
return {
counter: this.initialCounter
}
}
- 定义一个计算属性,处理
prop
的值并返回:
props: ['size'],
computed: {
normalizedSize: function () {
return this.size.trim().toLowerCase()
}
}
第一个方法相当于创建了原始 prop
的副本,之后怎么操作数据都是操作的子组件数据,不会影响到父组件数据;第二个方法,注意 trim()
会返回一个处理完成后的新字符串,同样不会影响到父组件数据(原字符串)。之后如果父组件确实要用到这个处理后的值,就通过 $emit
的方式传给父组件即可。
拿前面的例子来说,我们想要利用 prop
这个数据实现双向绑定,可以这么写:
<div id="app">
<cpn v-bind:value2="value"></cpn>
</div>
<template id="cpn">
<input type="text" v-model="value3">
<h2>{{value3}}</h2>
</template>
const cpn = {
template:"#cpn",
props:["value2"],
data:{
value3:this.value2
}
}
const app = new Vue({
el:'#app',
data:{
value:0
},
components:{
cpn
}
})
这样子就不会报错了,因为现在我们操作的是子组件自己的数据,和 prop
无关。
还要注意一个问题:
注意在 JavaScript 中对象和数组是通过引用传入的,所以对于一个数组或对象类型的 prop 来说,在子组件中改变这个对象或数组本身将会影响到父组件的状态。
比如下面这段代码:
<div id="app">
<h2>父组件数据:{{parent}}</h2>
<cpn v-bind:obj1="parent"></cpn>
</div>
<template id="cpn">
<div>
<h2>子组件数据:{{son}}</h2>
<input type="text" v-model="son.age">
</div>
</template>
const cpn = {
template:"#cpn",
props:["obj1"],
data(){
return {
son:this.obj1
}
}
}
const app = new Vue({
el:'#app',
data:{
parent:{age:20}
},
components:{
cpn
}
})
这里的 this.obj1
是引用,赋值给了 son
,所以 son
实际上还是指向了父组件的数据,对 son.age
的修改依然会影响到父组件,如图:
所以,我们实际上需要的是一个对象副本。因为对象属性都是基本类型,这里只用浅拷贝即可(如果对象属性还是对象,就得用深拷贝):
const cpn = {
template:"#cpn",
props:["obj1"],
data(){
return {
// son:this.obj1
son:Object.assign({},this.obj1)
}
}
}
之后会发现,子组件的数据操作不再影响到父组件:
如何理解 Vuex 的单向数据流
参考:
https://cn.vuejs.org/v2/guide/components-props.html
https://juejin.im/entry/59e8b8a8518825579d131e51